
這篇文章會比較 Collection 和 Sequence 的效能差異,整理出什麼時候該用哪個,並做 Sequence 篇的總回顧
| Kotlin | C# | 備註 |
|---|---|---|
list.filter { }.map { } |
list.Where().Select().ToList() |
Kotlin 預設 Eager,C# LINQ 預設 Lazy |
list.asSequence().filter { } |
list.Where() |
Kotlin 要手動切 Lazy,C# 本來就是 Lazy |
seq.take(5).toList() |
seq.Take(5).ToList() |
兩邊都是只走必要元素就停 |
JMH @Benchmark |
BenchmarkDotNet [Benchmark] |
各自生態的 microbenchmark 工具 |
Kotlin 跟 C# 的差別在 API 預設值。C# LINQ 的轉換運算子通常延遲執行,Kotlin 的 Collection 操作預設 Eager,想建立 Lazy 管線得呼叫 asSequence()。終端操作在兩邊都會觸發求值
// 100 個元素
val small = (1..100).toList()
// Collection 版
small.filter { it % 2 == 0 }.map { it * 10 }.first()
// Sequence 版
small.asSequence().filter { it % 2 == 0 }.map { it * 10 }.first()
這段最後呼叫 first(),兩個版本做的工作不一樣。Collection 版會先完成整個 filter 和 map;Sequence 版找到第一個偶數後就停止。因此即使只有 100 個元素,Sequence 仍可能因短路而占優勢
如果兩邊都完整產出結果,例如最後呼叫 toList(),兩邊各有各的成本。Collection 版走 inline 與直接迴圈,但每個步驟都會配置一個中間 List;Sequence 版要建立包裝物件並透過 Iterator 鏈呼叫,不過只在終端操作配置一次結果。這些成本會受到 JDK、Kotlin 版本、JIT 與管線內容影響,不能只用「小集合」推導勝負,本篇後面的 benchmark 就跑出了跟直覺相反的結果
這也是我重跑 benchmark 前會先檢查的事:兩邊的輸入、轉換步驟和終端操作是否真的一致
// 1,000,000 個元素,只需要前 5 個結果
val large = (1..1_000_000).toList()
// Collection 版:filter 走完 100 萬個,map 走完 filter 結果,最後 take 5 個
large.filter { it % 7 == 0 }.map { it.toLong() * it }.take(5)
// Sequence 版:找到 5 個就停
large.asSequence().filter { it % 7 == 0 }.map { it.toLong() * it }.take(5).toList()
Collection 版必須把 100 萬個元素全部 filter 一遍,再全部 map 一遍,最後才 take 前 5 個。產出兩個大型中間 List
Sequence 版從頭開始走,碰到 7 的倍數就 map 一下,收集到 5 個就停。可能只需要走 35 個元素(7, 14, 21, 28, 35)就搞定了
// 找到第一個符合條件的
employees.asSequence()
.filter { it.salary > 70000 }
.map { it.name }
.first()
day 27 的測試驗證了該組資料中 Eager 版處理 11 次,Lazy 版只處理 2 次。這說明短路能減少運算次數,但實際耗時仍要量測
前面只能算程式碼層面的推測。要知道自己的服務會不會變快,我還是會用 JMH(Java Microbenchmark Harness)量測
這個系列的主專案是 Day 01 建立的 Kotlin Toolchain 專案,裡面沒有 Gradle 設定。JMH 需要 Gradle plugin,因此範例專案另外放一個 benchmark/ Gradle 子專案,不要把下面的設定加到根目錄
想簡單一點的話,不用自己從零刻設定檔,直接用 IntelliJ IDEA 的 New Project 建一個一般的 Kotlin 專案就好。Language 選 Kotlin、Build system 選 Gradle、Gradle DSL 選 Kotlin,JDK 挑一個 21 以上的版本。IDEA 會把 build.gradle.kts、settings.gradle.kts 和 Gradle Wrapper 都準備好,剩下的只有兩件事:在 build.gradle.kts 補上 JMH plugin,以及把 benchmark 原始碼放進 src/jmh/kotlin/
專案結構大概就長這樣
benchmark/
├── build.gradle.kts
├── settings.gradle.kts
├── gradlew
├── gradle/wrapper/
└── src/jmh/kotlin/FilterMapBenchmark.kt
settings.gradle.kts 只需要設定專案名稱,IDEA 產生的內容可以直接留著
rootProject.name = "kotlin-lambda-benchmark"
build.gradle.kts 使用 Kotlin 2.4.0 與 JMH plugin 0.7.3,IDEA 產生的 group、version、dependencies 等區塊保留即可
plugins {
kotlin("jvm") version "2.4.0"
id("me.champeau.jmh") version "0.7.3"
}
repositories {
mavenCentral()
}
jmh {
warmupIterations.set(5)
iterations.set(10)
fork.set(1)
}
這裡有個容易踩到的地方:benchmark 原始碼一定要放在 src/jmh/kotlin/,不是 IDEA 預設幫你開的 src/main/kotlin/。JMH plugin 只把 jmh-core 掛到 jmh 這個 source set,檔案放到 src/main/kotlin/ 的話,org.openjdk.jmh.annotations 底下的 import 會全部變成 Unresolved reference,編譯直接失敗
JMH 也不接受放在 default package 的 benchmark class,所以原始碼要先宣告 package
package benchmark
import java.util.concurrent.TimeUnit
import org.openjdk.jmh.annotations.Benchmark
import org.openjdk.jmh.annotations.BenchmarkMode
import org.openjdk.jmh.annotations.Fork
import org.openjdk.jmh.annotations.Measurement
import org.openjdk.jmh.annotations.Mode
import org.openjdk.jmh.annotations.OutputTimeUnit
import org.openjdk.jmh.annotations.Scope
import org.openjdk.jmh.annotations.State
import org.openjdk.jmh.annotations.Warmup
@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 5)
@Measurement(iterations = 10)
@Fork(1)
open class FilterMapBenchmark {
val small = (1..100).toList()
val medium = (1..10_000).toList()
val large = (1..1_000_000).toList()
@Benchmark
fun smallCollection(): List<Int> =
small.filter { it % 2 == 0 }.map { it * 10 }
@Benchmark
fun smallSequence(): List<Int> =
small.asSequence().filter { it % 2 == 0 }.map { it * 10 }.toList()
@Benchmark
fun mediumCollection(): List<Int> =
medium.filter { it % 2 == 0 }.map { it * 10 }
@Benchmark
fun mediumSequence(): List<Int> =
medium.asSequence().filter { it % 2 == 0 }.map { it * 10 }.toList()
@Benchmark
fun largePartialCollection(): List<Long> =
large.filter { it % 7 == 0 }.map { it.toLong() * it }.take(5)
@Benchmark
fun largePartialSequence(): List<Long> =
large.asSequence().filter { it % 7 == 0 }.map { it.toLong() * it }.take(5).toList()
}
Kotlin class 預設是 final,但 JMH 的 bytecode generator 需要繼承 benchmark class 來產生測試程式,因此這裡必須使用 open class。只跑 jmhClasses 仍可能看起來正常;./gradlew jmhJar 會真正執行 bytecode generator,可以把這類問題擋下來
❯ ./gradlew jmhJar
BUILD SUCCESSFUL in 387ms
5 actionable tasks: 5 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.6.0/userguide/configuration_cache_enabling.html
看到 BUILD SUCCESSFUL 就表示 source set、package 宣告和 open class 這三件事都對了,可以往下跑
跑之前可以先寫下自己的假設:完整物化時,Collection 可能受益於 inline 與較直接的迴圈;只取前幾筆時,Sequence 可以少走大量元素。等一下看結果會發現,其中一個假設沒有成立
進入 benchmark/ 後執行 ./gradlew jmh。這組設定跑完大約 15 分鐘,最後會印出這樣的摘要
Benchmark Mode Cnt Score Error Units
FilterMapBenchmark.largePartialCollection avgt 10 2616.002 ± 101.703 us/op
FilterMapBenchmark.largePartialSequence avgt 10 0.088 ± 0.006 us/op
FilterMapBenchmark.mediumCollection avgt 10 47.921 ± 3.844 us/op
FilterMapBenchmark.mediumSequence avgt 10 55.268 ± 9.469 us/op
FilterMapBenchmark.smallCollection avgt 10 0.397 ± 0.013 us/op
FilterMapBenchmark.smallSequence avgt 10 0.309 ± 0.004 us/op
Score 是每次操作的平均耗時,Error 是誤差範圍,數字越小越快。這組數字的執行環境是 Apple M2 Max、macOS 26.5.2、JDK 21.0.11(Temurin)、Kotlin 2.4.0、JMH 1.36,fork 1 次、warmup 5 次、measurement 10 次,每次 10 秒。換機器、換 JDK 版本數字就會不一樣,所以下面談的是差距的量級,不是絕對值。你自己跑的時候,這些環境資訊也要一起記下來,不然過幾個月回頭看會不知道這組數字是什麼條件下量的
三組分開看
大集合只取前 5 筆:2616 us/op 對 0.088 us/op,差了將近三萬倍。Collection 版把 100 萬個元素完整 filter 一遍再 map 一遍,Sequence 版走到第 35 個就收工。這種量級的差距不是常數項的優化能補回來的,前面推測 Sequence 能少做很多工作,這裡得到了驗證
中集合完整物化:Collection 是 47.9 us/op、Sequence 是 55.3 us/op,Collection 快了大約 15%。但把誤差算進去,兩邊的區間其實有重疊(44.1 ~ 51.8 對 45.8 ~ 64.7),這種程度的差異我不會拿來當選型依據
小集合完整物化:0.397 us/op 對 0.309 us/op,Sequence 反而比較快,跟前面「Collection 受益於 inline」的假設相反。原因在配置量:Collection 版的 filter 產生一個中間 ArrayList、map 再產生一個,Sequence 版則只在 toList() 配置最後那一個。100 個元素的規模下,inline 省下來的呼叫成本補不回多配置一個 List 的代價
結果跟假設相反的時候,先檢查 benchmark 是否公平:兩邊的輸入、轉換步驟和終端操作有沒有真的一致。確認公平之後,再回頭看 JIT、配置量與資料分布。這次就是配置量的差異蓋過了 inline 的優勢
時間之外,記憶體也要量。Collection 管線會為每個轉換步驟建立中間 List,Sequence 則建立包裝物件並在終端操作建立最終結果。加掛 JMH 的 GC profiler 就能看到實際配置量,直接對 jmhJar 產生的 jar 下 -prof gc
❯ java -jar build/libs/benchmark-1.0-SNAPSHOT-jmh.jar -prof gc -f 1 -wi 5 -i 10
Benchmark Mode Cnt Score Units
FilterMapBenchmark.largePartialCollection:·gc.alloc.rate.norm avgt 10 5921344.104 B/op
FilterMapBenchmark.largePartialSequence:·gc.alloc.rate.norm avgt 10 176.000 B/op
FilterMapBenchmark.mediumCollection:·gc.alloc.rate.norm avgt 10 175184.002 B/op
FilterMapBenchmark.mediumSequence:·gc.alloc.rate.norm avgt 10 155272.002 B/op
FilterMapBenchmark.smallCollection:·gc.alloc.rate.norm avgt 10 1864.000 B/op
FilterMapBenchmark.smallSequence:·gc.alloc.rate.norm avgt 10 1680.000 B/op
想走 Gradle 的話,在 build.gradle.kts 的 jmh 區塊加一行 profilers.set(listOf("gc")),./gradlew jmh 就會自動帶上
gc.alloc.rate.norm 是每次操作配置的位元組數。要注意這是另一次執行,而且 profiler 本身有 overhead,所以這份輸出的耗時跟上一份不會完全一樣,這裡只看配置量
大集合那組是 5.9 MB/op 對 176 B/op。Collection 版產生兩個各裝十幾萬個元素的中間 List,Sequence 版只配置最後那五筆的容器,跟耗時的差距是同一個量級
小集合那組 1864 B/op 對 1680 B/op,Sequence 少了大約 10%,跟它耗時較短的方向一致,前面「配置量蓋過 inline 優勢」的說法在這裡有數字撐著
中集合那組比較有意思:Sequence 配置 155 KB、Collection 配置 175 KB,Sequence 少了大約 11%,但時間上反而是 Sequence 慢一些。配置量少不等於跑得快,Iterator 鏈的呼叫成本在這個規模開始吃掉配置量省下來的部分。這也是為什麼不要只看單一指標就決定用哪一個
把耗時和配置量放在一起,三組情境的全貌是這樣,每格都是「Collection vs Sequence」
| 情境 | 耗時(us/op) | 配置量(B/op) | 結果 |
|---|---|---|---|
| 小集合完整物化 | 0.397 vs 0.309 | 1,864 vs 1,680 | Sequence 快 22%、少配置 10% |
| 中集合完整物化 | 47.9 vs 55.3 | 175,184 vs 155,272 | Collection 略快(誤差重疊)、Sequence 少配置 11% |
| 大集合取前 5 筆 | 2616 vs 0.088 | 5,921,344 vs 176 | Sequence 兩項都好三萬倍 |
這張表講完了三件事
第一,真正的分水嶺是能不能短路,不是資料量。三組裡唯一出現數量級差距的是能短路的那組;兩組完整物化的情境,差距都在同一個數量級內,甚至互有輸贏。管線末端是 first、take、any 的時候,Sequence 帶來的是質變;末端是 toList 的時候,換不換只是零點幾倍的差別
第二,資料量本身沒有決定性。100 個元素這組 Sequence 贏,10,000 個元素這組反而 Collection 贏。如果照「資料量大才用 Sequence」這種說法去判斷,這兩組都會判斷錯
第三,耗時和配置量不一定同向。中集合那組 Sequence 少配置 11%,時間卻沒有跟著變快,反而是略慢的那一邊。只挑一個指標看,很容易得到跟另一個指標相反的結論
所以在完整物化的情境,我不會為了效能去把 Collection 改寫成 Sequence,差異小到不值得,哪一版讀起來清楚就用哪一版。真正值得換的是管線能短路、或者來源根本是無限的時候,那才是 Sequence 的主場
我實務上的判斷順序不是資料筆數,而是結果要怎麼用
first、take、any):優先考慮 Sequence決策流程
需要無限序列或提前停止?
├── 是 → 優先評估 Sequence
└── 否 → 是否要避免多個大型中間集合?
├── 是 → 比較 Sequence 與 Collection 的配置和耗時
└── 否 → 直接使用 Collection
一般的集合處理,我會先用比較直接的 Collection。看到 first、take、any 這類可短路的終端操作,或管線會建立幾個大型中間 List,才把 Sequence 納入選項。若這段程式碼真的在效能敏感路徑,再讓 benchmark 決定
| 面向 | Kotlin Sequence | C# LINQ | Java Stream |
|---|---|---|---|
| 預設行為 | Lazy | Lazy | Lazy |
| 可重複遍歷 | 通常可以,部分實作限一次 | 取決於來源 | 不行(一次性) |
| 平行處理 | 無內建 | PLINQ | parallelStream() |
| 建立方式 | asSequence() |
原生 | stream() |
| Collection 操作 | Eager(預設) | Lazy(預設) | 沒有對應 |
Java Stream 用完就不能再用,要遍歷兩次得建立兩個 Stream。Kotlin Sequence 通常可以重複遍歷,但官方 API 也有一次性實作;C# LINQ 是否能安全重複列舉則取決於來源與查詢內容
Kotlin Collection 的 filter、map 預設 Eager,延遲處理時要明確切到 Sequence。C# LINQ 的轉換運算子本來就採延遲執行;Java List 沒有對應的 map、filter 鏈式 API,這類操作通常從 stream() 開始。三邊語法相似,但切換點不同
Kotlin Sequence 沒有內建的平行處理 API。Coroutine 也不會自動讓 Sequence 平行化;需要自行切分工作、選擇 dispatcher 並處理結果順序。C# 有 PLINQ(AsParallel()),Java 有 parallelStream()
五篇走過的路線
| 篇 | 主題 | 學到什麼 |
|---|---|---|
| 27 | Eager vs Lazy | 中間集合的浪費問題,Sequence 的逐元素處理 |
| 28 | 基礎設施 | sequenceOf、asSequence、generateSequence 的實作 |
| 29 | filter / map | 包裝 Sequence 模式,為什麼不用 inline |
| 30 | 中間 vs 終端 | take 是中間操作,first/toList 是終端操作 |
| 31 | 效能與選擇 | 依管線、終端操作與實測結果選擇 |
從 day 05 到 day 31,我們手刻了具代表性的 Eager Collection 與 Lazy Sequence 操作。Eager 版多以 inline、for 迴圈和 ArrayList 實作;Lazy 版則用包裝 Sequence 與 Iterator 的拉取模式
第八部分手刻過的 Sequence 函式整理如下
| 函式 | 篇號 | 用途 |
|---|---|---|
mySequenceOf |
day 28 | 把幾個元素包成一個 Sequence |
myEmptySequence |
day 28 | 產生一個空的 Sequence |
myAsSequence |
day 28 | 把現有 Iterable 轉成 Lazy 的 Sequence |
myGenerateSequence |
day 28 | 用 seed 和規則產生(可無限)序列 |
myFilter(Sequence 版) |
day 29 | 中間操作,逐元素篩選、回傳新 Sequence |
myMap(Sequence 版) |
day 29 | 中間操作,逐元素轉換、回傳新 Sequence |
myTake |
day 30 | 中間操作,只取前 n 個就停 |
myFirst |
day 30 | 終端操作,取第一個並結束遍歷 |
myToList |
day 30 | 終端操作,把整個 Sequence 收進一個 List |
Collection 和 Sequence 的差異不只在資料量。是否能短路、是否建立中間集合、是否完整物化,以及結果會不會重複使用,都會影響選擇。一般程式先選語意清楚的寫法;效能敏感路徑再用同條件 benchmark 驗證
下一篇(day 32)進入進階語法篇,看 Scope Functions 怎麼跟 Collection 操作搭配
同步刊登於 Blog
圖片來源:AI 產生